iT邦幫忙

2024 iThome 鐵人賽

DAY 22
0
JavaScript

Vue.js學習中的細節陷阱:30天自我學習指南系列 第 22

Day 22: SOLID - 開放封閉原則(OCP) 和 Vue的元件開發

  • 分享至 

  • xImage
  •  

今天來到認識另一個SOLID設計準則-開放封閉原則(OCP),老實說寫起來比較沒把握,因為大部分的探討資源大多是針對class物件去作延展探討,或是國外以React作深入探討,比較少使用Vue當作案例,用前端元件開發去思考,就試著搭用自己搜尋的相關資源去嘗試整合囉~鐵人賽就是一段探索自我的過程,加油!。

今日學習目標

  1. 認識SOLID - 開放封閉原則(Open Close Principle, OCP)
  2. 函式function擴充 - 混合模式(Mixin)
  3. VueUse組合式函式的擴充 - 高階函式(HOF)
  4. 前端元件UI樣式的開放封閉 - slot插槽
  5. 前端元件資料邏輯的開放封閉 - composable 組合式函式應用

開放封閉原則(Open Close Principle, OCP)

開放/封閉原則(Open/Closed Principle, OCP)是SOLID物件導向設計中的一個重要原則,目的在幫助系統在需求變化時更容易維護和擴展(extend)部分,根據這個原則會有幾項特點:

對擴展開放:

模組或元件應能夠透過添加新代碼的方式來擴展其功能,而不是修改現有的代碼。 這指的是在未來業務需求變化時,可以透過繼承、介面實現、或添加新的類別和方法這幾種方式來實現新的行為(對於類別class操作來說)。

對修改應該封閉:

模組或元件應盡量避免修改已有的代碼。透過這種方式,已經測試和驗證的代碼部分不應該因新功能的加入而引入新的缺陷或錯誤,造成已經穩定運行的程式碼執行中斷

  • 開放/封閉原則核心思想:

應透過增加,或者說保有原本代碼來擴充(而不是修改現有代碼)來適應新需求,從而保持系統的穩定性和可維護性。

延續昨天的機器人漫畫圖,原本機器人是設定只能做裁切功能,因為某些客人的需求將它內部改造成可以上漆的功能,但改造過程中工程師不小心把原本裁切功能改壞不能使用了。

(圖片出處)


函式function如何保持封閉開放?

一般情況下,我們接到需求變更的通知,通常的做法可能是直接在原本模組或函式上添加新功能,然而修改已經存在的源碼其實存在很大的風險,雖然我們可以盡量不動到已經設計好的部分。但是在專案上線運行一段時間後,開發人員發生變動時,這種風險可能會變得更大。

例如下面程式碼,一開始專案可能MVP最小可行需求是一台冰淇淋製造機,只有兩種口味。後來因為賣得很好,決定增加不同口味,工程師對原有函式變動加入新功能進去

但這樣看起來,每次隨著業務變動,程式碼也跟著變一次,壞掉的風險也增加一次?

// 冰淇淋製造機函式
// iceCreamMaker.js
export function createIceCreamMaker() {
  let iceCreamFlavors = ['chocolate', 'vanilla']; // 初始口味
  
  return {
    makeIceCream(flavor) {
      if (iceCreamFlavors.includes(flavor)) {
        console.log('您選的口味有貨,馬上給您做冰淇淋。');
      } else {
        console.log('哎呀,您選的口味我們沒有。');
      }
    },
    // 業務邏輯變動了,需要增加口味------有可能越加越多
    // 增加口味
    addFlavor(flavor) {
      if (!iceCreamFlavors.includes(flavor)) {
        iceCreamFlavors.push(flavor);
      } else {
        console.log('該口味已經存在。');
      }
    },
    // 獲取當前所有口味
    getFlavors() {
      return [...iceCreamFlavors]; // 返回口味列表的副本
    },
  };
}

函式的混入模式(Mixin pattern)

上次接觸工廠函式和類別其實有提到,工廠函式間的繼承關係可能比較沒這麼明顯,如果要結合舊有函式功能,並且新增新的函式功能時不動用到舊有程式碼,可以將兩者回傳的物件進行合併整合,有點像混入模式(Mixin pattern)

有看到一篇文章介紹 Functional Mixin , 是利用函式來擴展物件的功能,而不是使用傳統的類別繼承(class extend)。可以讓開發者根據需要,自由地組裝想要的模塊,而不必接收所有繼承的類別和方法,這大大增加了模組化的靈活性,而不用完整依循著父子類有上下層關係,可能靈活選擇自己想用的功能組裝進去。

  • 簡單步驟:

iceCreamMaker: 首先定義了一個可以製作冰淇淋的基本功能模組,它根據給定的可用口味來判斷是否能製作指定口味的冰淇淋。

flavorable: 當需求增加時,通過定義一個新的 flavorable 模組來擴展功能,這個模塊負責動態管理冰淇淋的口味。

組合成新功能: 利用 Object.assign() 將這兩個模組組合到一個新的物件中,讓這個物件同時具備製作冰淇淋和管理口味的功能。

// step 1 -------------------
const iceCreamMaker = (availableFlavors = ['chocolate', 'vanilla']) => o => {
  return Object.assign({}, o, {
    makeIceCream(flavor) {
      if (availableFlavors.includes(flavor)) {
        console.log('您選的口味有貨,馬上給您做冰淇淋。');
      } else {
        console.log('哎呀,您選的口味我們沒有。');
      }
      return this;
    }
  });
};

// 創建冰淇淋機
const myIceCreamMachine = iceCreamMaker()({});

// 使用
myIceCreamMachine.makeIceCream('chocolate'); // "您選的口味有貨,馬上給您做冰淇淋。"
myIceCreamMachine.makeIceCream('strawberry'); // "哎呀,您選的口味我們沒有。"

// step 2 當業務需求增加時,再額外添加延展函式----------------

// 新功能模塊:口味管理功能
const flavorable = (initialFlavors = []) => o => {
  let iceCreamFlavors = initialFlavors;

  return Object.assign({}, o, {
    addFlavor(flavor) {
      if (!iceCreamFlavors.includes(flavor)) {
        iceCreamFlavors.push(flavor);
        console.log(`成功新增口味:${flavor}`);
      } else {
        console.log(`${flavor} 已經存在!`);
      }
      return this;
    },
    getFlavors: () => [...iceCreamFlavors]
  });
};

// step 3 ------------------------------------
// 將 `flavorable` 和 `iceCreamMaker` 組合起來變成新的冰淇淋機
const createIceCreamMachine = () => {
  return Object.assign(
    {},
    flavorable(['chocolate', 'vanilla'])({}),
    iceCreamMaker()({})
  );
};

但是使用上依樣要注意先有順序問題,後面合併物件如果有屬性同名會覆蓋掉前面的模組物件,對於需要合併多功能模組物件時,可以考慮使用 pipeline function ,有點像是排程程式碼,依序執行合併再到下一個:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);

// 使用 pipe 來組合 Functional Mixins 加入不同的擴充功能

const createIceMachine = ([...口味]) => pipe(
  flavorable,
  getFlavors
)({});  // 從一個空物件開始


VueUse的組合式函式的擴充 - 高階函式(HOF)

在 Vue 常見的組合式函式庫VueUse中則是採取利用 JavaScript 高階函式(Higher-Order Functions, HOF) ,最簡單的方式來達成功能的擴充。

高階函數(HOF, Higher-Order Function) 是一種接收函數作為參數或返回函數作為結果的函數。它是一種在函數式編程中常見的設計模式,可以讓函數具有更強的靈活性和可重用性。

像是 useRefHistory,這類功能通常會引入另一個 useManualRefHistory,透過變數傳遞和呼叫去利用組裝好的函式。

function execute(fn) {
  fn();  // 執行傳入的函數
}

function sayHello() {
  console.log('Hello!');
}

execute(sayHello);  // 傳入函數作為參數,並在內部調用
  • useRefHistory
export function useRefHistory<Raw, Serialized = Raw>(
  source: Ref<Raw>,
  options: UseRefHistoryOptions<Raw, Serialized> = {},
): UseRefHistoryReturn<Raw, Serialized> {
  // 略

 const manualHistory = useManualRefHistory(source, { ...options, clone: options.clone ||    
   deep, setSource })

元件的開放封閉(OCP in Vue Components)

昨天我們再認識單一職責的結尾有提到,UI元件有 負責畫面顯示 和負責 商業邏輯和API資料溝通 等,因此開放封閉這個觀念可以指的是:

  • 對UI樣式的組合可以開放封閉
  • 對資料行為邏輯可以開放封閉

UI元件組合開放封閉(slot)

可以使用上次介紹過的 元件插槽slot: 透過插槽slot提供靈活的擴展擴展空間,使用這個元件的使用者可以自己注入自定義的內容,而無需修改已經設定好的元件本身UI樣式。

<!-- BaseButton.vue 基本的按鈕樣式,提供slot讓父組件使用時,能夠自定義插入內容 -->
<template>
  <button :class="buttonClass" @click="handleClick">
    <slot>按鈕</slot>
  </button>
</template>
  • 比方說製作一個 LoadingButton:

使用 BaseButton 作為基礎,並透過組合增加了 loading 狀態一般按鈕樣式作成組合,不需要修改原始的 BaseButton 代碼,避免了因為程式碼更動造成潛在的錯誤或不穩定性。

範例

或是像上次有介紹到固定互動邏輯,但UI版型不一樣,利用 slot props 無渲染元件(renderless component)實作切換按鈕的案例。

範例


元件對資料行為邏輯可以開放封閉

  • 可擴展驗證功能的input元件

元件資料行為的開放封閉,都常透過共用邏輯的封裝,也就是組合式函式(composable)的部分可以設計得更有彈性和具備擴充性,例如我們來實作一個可以增加驗證功能的 input 輸入框。

首先我們先分析需求一下,「可擴展驗證功能的input元件,這句話是不是代表著這個UI組件本身已經不是基本的UI組件,除了Input外觀外,還多了驗證功能這個需求,可以理解成我們需要製作的是一個需要拆分結構的元件,避免一個元件檔案承攬太多職責。

同時「可擴展驗證功能」,代表這次設定驗證資料邏輯,可能會在未來有所變動新增加入新的驗證邏輯,也就需要納入開放封閉原則(OCP),設計元件時新增邏輯就要不能破壞原本元件檔。

  • 基本的的UI樣式

會有狀態包含v-model資料綁定驗證錯誤訊息等狀態。

<template>
  <div>
    <input v-model="value" @input="validate" />
    <ul v-if="hasError">
      <li v-for="(error, index) in errors" :key="index">{{ error }}</li>
    </ul>
  </div>
</template>
  • Composable useValidation函式

因為驗證邏輯未來會有擴充需求,驗證邏輯會因商業需求組裝而變化,代表著 errors 錯誤訊息資料validate 驗證函式需要抽出成為獨立邏輯,不用和元件綁在一起耦合住。

因此將驗證邏輯包裝在一個可重複利用的 useValidation函式中,讓它可以在各種元件中使用,裡面通常是包含驗證error狀態。

validate 驗證函式會執行多個不同類型的驗證功能rules ,rules 可以再拆分成另一個檔案,專門設計成不同的驗證邏輯,讓 validate 驗證函式專注在執行驗證過程這個環節上。

// useValidation.js

import { ref, computed } from 'vue';

export function useValidation(value, rules) {
  const errors = ref([]);

  const validate = () => {
    errors.value = rules
      .map((rule) => rule(value.value))
      .filter((error) => error !== null);
  };

  const hasError = computed(() => errors.value.length > 0);

  return {
    errors,
    hasError,
    validate,
  };
}
  • 驗證規則validateRules
// validatRules.js

export const required = (value) => {
  return value ? null : 'This field is required.';
};

export const minLength = (min) => (value) => {
  return value.length >= min ? null : `Minimum length is ${min}.`;
};

export const emailFormat = (value) => {
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailPattern.test(value) ? null : 'Invalid email format.';
};

TextInput — 組裝成顯示驗證錯誤的元件

使用 Composable 組合驗證策略,父元件可以靈活應用不同的驗證組合,子元件只需要關心如何顯示錯誤訊息,除了達到真正的分離關注點,每個元件和設定模組都有自己的中心單一職責(SRP): UI顯示驗證狀態驗證邏輯規則。 同時透過這樣的設計模式,也實現開放/封閉原則(OCP):

完成範例

  • 擴展性

每當需要新的驗證邏輯,只需要新增一個新的驗證策略函式,並在元件中組合使用。

  • 封閉性:

不需要修改 TextInputuseValidation的原始程式碼,所有擴展皆透過組合式函式的方式實現。


總結

開放/封閉原則(OCP),其實正確來說應該是強調在,不修改原有代碼的情況下進行擴展,並且擴展邏輯應該獨立運作,而不應與舊有邏輯互相干擾或耦合,這樣才能真正遵守 OCP,避免破壞既有的穩定性。


(圖片出處)

  • 獨立擴展

新增的擴展邏輯不應該依賴或影響現有代碼的運行方式,每一段模組或程式碼盡量式獨立關係。這樣的設計可以確保擴展部分能夠獨立開發、測試和部署,不會破壞現有的穩定性。

  • 不修改原有邏輯

當新需求來臨時,應透過新增代碼來擴展功能,而不是直接修改已有的功能。這樣可以確保原有邏輯和功能不受新擴展的影響,保持穩定性和可預測性。

雖然今天內容有點文謅謅的,不過盡量找到一些案例去說明,希望接下來的其他SOLID準則也能夠順利學習~加油囉


學習資源

  1. https://medium.com/@f40507777/開放封閉原則-open-closed-principle-31d61f9d37a5
  2. https://stackoverflow.com/questions/61046298/how-to-properly-replace-extends-using-functional-programming
  3. https://medium.com/javascript-scene/functional-mixins-composing-software-ffb66d5e731c#a7f1
  4. https://www.patterns.dev/vanilla/mixin-pattern
  5. https://raganwald.com/2015/06/17/functional-mixins.html

上一篇
Day 21: SOLID - 單一職責原則(SRP) 和 Vue的元件開發
下一篇
Day 23: SOLID - 里式替換原則(LSP) 和 Vue的組合式函式擴充
系列文
Vue.js學習中的細節陷阱:30天自我學習指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言